import {z} from 'zod'
import {nanoid} from 'nanoid'
import {studioAuth} from 'src/utils/authUtils'
import {v4} from 'uuid'
import * as t from '../../api/trpc'
import prisma from 'src/prisma'
import {calculatePKCECodeChallenge} from 'oauth4webapi'
import type {studioAuthTokens} from '~/types'
import {studioAccessScopes} from '~/types'

export const userCodeLength = 8
export const FLOW_CHECK_INTERVAL = 1000

export const studioAuthRouter = t.createRouter({
  deviceCode: t.publicProcedure
    .input(
      z.object({
        nounce: z
          .string()
          .min(32)
          .describe(
            `This is a random string that should be unique for each client flow. It is generated by the client, will be included in the refresh token.`,
          ),
        codeChallenge: z
          .string()
          .min(43)
          .max(1024)
          .describe(`The code_challenge as defined in OAuth RFC 7636`),
        codeChallengeMethod: z
          .enum(['S256'])
          .describe(`The code_challenge_method as defined in OAuth RFC 7636`),

        scopes: studioAccessScopes.scopes.describe(
          `The scopes the client is requesting access to. In case \`originalIdToken\` is provided, only the additional scopes should be defined here`,
        ),
        originalIdToken: z
          .string()
          .optional()
          .describe(
            `In case the client (the studio) already has an idToken but is requesting more access, it should provide the original idToken. (This happens e.g. when the studio has access to workspaceA, but now also needs access to workspaceB)`,
          ),
      }),
    )
    .output(
      z.object({
        interval: z
          .number()
          .int()
          .min(1000)
          .describe(
            'If 1000, it means the library should check the `$.tokens()` every 1000ms or longer.',
          ),
        verificationUriComplete: z
          .string()
          .url()
          .describe(
            `The URL that the user should be redirected to (or the url to be open via popup) ` +
              `for the user to log in. Note that if the user is already logged ` +
              `into the app, they won't be prompted to log in again.`,
          ),
        deviceCode: z
          .string()
          .min(72)
          .describe(`A unique token that should be passed to $.tokens()`),
      }),
    )
    .mutation(async (opts) => {
      const userCode = nanoid(userCodeLength)
      const deviceCode = v4() + v4()

      await prisma.deviceAuthorizationFlow.create({
        data: {
          nounce: opts.input.nounce,
          createdAt: new Date().toISOString(),
          lastCheckTime: new Date().toISOString(),
          codeChallenge: opts.input.codeChallenge,
          codeChallengeMethod: opts.input.codeChallengeMethod,
          deviceCode,
          tokens: '',
          userCode: userCode,
          state: 'initialized',
        },
      })

      return {
        interval: FLOW_CHECK_INTERVAL,
        verificationUriComplete:
          process.env.NEXT_PUBLIC_WEBAPP_URL +
          `/api/studio-auth?userCode=${userCode}`,
        deviceCode,
      }
    }),
  tokens: t.publicProcedure
    .input(
      z.object({
        deviceCode: z
          .string()
          .describe(`The \`deviceCode\` generated by deviceCode()`),
        codeVerifier: z
          .string()
          .describe(`The \`codeVerifier\` as defined in 7636`),
      }),
    )
    .output(
      z.union([
        z.object({
          isError: z.literal(true),
          error: z.enum([
            'invalidDeviceCode',
            'invalidCodeVerifier',
            'userDeniedAuth',
            'slowDown',
            'notYetReady',
          ]),
          errorMessage: z.string(),
        }),
        z.object({
          isError: z.literal(false),
          accessToken: z.string(),
          idToken: z.string(),
        }),
      ]),
    )
    .mutation(async ({input}) => {
      const flow = await prisma.deviceAuthorizationFlow.findFirst({
        where: {deviceCode: input.deviceCode},
      })
      if (!flow) {
        return {
          isError: true,
          error: 'invalidDeviceCode',
          errorMessage:
            'The deviceCode is invalid. It may also have been expired, or already used.',
        }
      }
      await prisma.deviceAuthorizationFlow.update({
        where: {deviceCode: input.deviceCode},
        data: {lastCheckTime: new Date().toISOString()},
      })
      // if flow.lastCheckTime is more recent than 5 seconds ago, return the same thing as last time
      if (
        new Date(flow.lastCheckTime).getTime() >
        Date.now() - FLOW_CHECK_INTERVAL
      ) {
        return {
          isError: true,
          error: 'slowDown',
          errorMessage: 'You are checking too often. Slow down.',
        }
      }

      switch (flow.state) {
        case 'initialized':
          return {
            isError: true,
            error: 'notYetReady',
            errorMessage: `The user hasn't decided to grant/deny access yet.`,
          }
        case 'userDeniedAuth':
          return {
            isError: true,
            error: 'userDeniedAuth',
            errorMessage: `The user denied access.`,
          }
        case 'userAllowedAuth':
          const tokens = JSON.parse(flow.tokens)

          const codeChallenge = await calculatePKCECodeChallenge(
            input.codeVerifier,
          )
          if (codeChallenge !== flow.codeChallenge) {
            return {
              isError: true,
              error: 'invalidCodeVerifier',
              errorMessage: `The codeVerifier is invalid.`,
            }
          }

          await prisma.deviceAuthorizationFlow.update({
            where: {deviceCode: input.deviceCode},
            data: {state: 'tokenAlreadyUsed'},
          })

          return {
            isError: false,
            accessToken: tokens.accessToken,
            idToken: tokens.refreshToken,
          }
        // otherwise
        default:
          console.error('Invalid state', flow.state)
          return {
            isError: true,
            error: 'invalidDeviceCode',
            errorMessage:
              'The preAutenticationToken is invalid. It may also have been expired, or already used.',
          }
      }
    }),
  invalidateRefreshToken: t.publicProcedure
    .input(
      z.object({
        refreshToken: z.string(),
      }),
    )
    .output(
      z.union([
        z.object({
          isError: z.literal(true),
          error: z.enum(['unknown']),
          errorMessage: z.string(),
        }),
        z.object({
          isError: z.literal(false),
        }),
      ]),
    )
    .mutation(async ({input}) => {
      try {
        await studioAuth.destroySession(input.refreshToken)
        return {isError: false}
      } catch (err) {
        console.error(err)
        return {
          isError: true,
          error: 'unknown',
          errorMessage: `An unknown error occured.`,
        }
      }
    }),
  refreshAccessToken: t.publicProcedure
    .input(
      z.object({
        refreshToken: z.string(),
      }),
    )
    .output(
      z.union([
        z.object({
          isError: z.literal(true),
          error: z.enum(['invalidRefreshToken', 'unknown']),
          errorMessage: z.string(),
        }),
        z.object({
          isError: z.literal(false),
          accessToken: z.string(),
          refreshToken: z
            .string()
            .describe(
              `The new refresh token. The old refresh token is now invalid.`,
            ),
        }),
      ]),
    )
    .mutation(async ({input}) => {
      try {
        const {accessToken, refreshToken} = await studioAuth.refreshSession(
          input.refreshToken,
        )
        return {isError: false, accessToken, refreshToken}
      } catch (err: any) {
        console.error(err)
        if (err.message === 'Invalid refresh token') {
          return {
            isError: true,
            error: 'invalidRefreshToken',
            errorMessage: `The refresh token is invalid.`,
          }
        } else {
          return {
            isError: true,
            error: 'unknown',
            errorMessage: `An unknown error occured.`,
          }
        }
      }
    }),
  destroyIdToken: t.publicProcedure
    .input(z.object({idToken: z.string()}))
    .output(
      z.union([
        z.object({
          isError: z.literal(true),
          error: z.enum(['unknown']),
          errorMessage: z.string(),
        }),
        z.object({
          isError: z.literal(false),
        }),
      ]),
    )
    .mutation(async ({input}) => {
      try {
        await studioAuth.destroySession(input.idToken)
        return {isError: false}
      } catch (err) {
        console.error(err)
        return {
          isError: true,
          error: 'unknown',
          errorMessage: `An unknown error occured.`,
        }
      }
    }),

  canIEditProject: t.publicProcedure
    .input(
      z.object({
        studioAuth: studioAuth.input,
        projectId: z.string(),
      }),
    )
    .output(
      z.union([
        z.object({canEdit: z.literal(true)}),
        z.object({
          canEdit: z.literal(false),
          reason: z.enum(['AccessTokenInvalid', 'UserHasNoAccess', 'Unknown']),
        }),
      ]),
    )
    .query(async (opts) => {
      let payload!: studioAuthTokens.AccessTokenPayload
      try {
        payload = await studioAuth.verifyStudioAccessTokenOrThrow(opts)
      } catch (err) {
        return {canEdit: false, reason: 'AccessTokenInvalid'}
      }
      const {userId} = payload
      const proj = await prisma.workspace.findFirst({
        where: {
          // TODO check if user has access to project
          // userId,
          id: opts.input.projectId,
        },
      })
      if (proj) {
        return {canEdit: true}
      } else {
        return {canEdit: false, reason: 'UserHasNoAccess'}
      }
    }),
})
